route.js 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import fs from "node:fs";
  2. import fsp from "node:fs/promises";
  3. import path from "node:path";
  4. import { Readable } from "node:stream";
  5. import { getSession } from "@/lib/auth/session";
  6. import { canAccessBranch } from "@/lib/auth/permissions";
  7. import {
  8. withErrorHandling,
  9. badRequest,
  10. unauthorized,
  11. forbidden,
  12. notFound,
  13. ApiError,
  14. } from "@/lib/api/errors";
  15. import { mapStorageReadError } from "@/lib/api/storageErrors";
  16. export const dynamic = "force-dynamic";
  17. export const runtime = "nodejs";
  18. const BRANCH_RE = /^NL\d+$/;
  19. const YEAR_RE = /^\d{4}$/;
  20. const MONTH_RE = /^(0[1-9]|1[0-2])$/;
  21. const DAY_RE = /^(0[1-9]|[12]\d|3[01])$/;
  22. function getNasRootOrThrow() {
  23. const root = process.env.NAS_ROOT_PATH;
  24. if (!root) {
  25. throw new ApiError({
  26. status: 500,
  27. code: "FS_STORAGE_ERROR",
  28. message: "Internal server error",
  29. });
  30. }
  31. return root;
  32. }
  33. function isSafeFilename(name) {
  34. if (typeof name !== "string") return false;
  35. const trimmed = name.trim();
  36. if (!trimmed) return false;
  37. // Reject special path segments
  38. if (trimmed === "." || trimmed === "..") return false;
  39. // Reject any path separators (defense-in-depth)
  40. if (trimmed.includes("/") || trimmed.includes("\\")) return false;
  41. // Reject control chars (header injection)
  42. if (/[\r\n\t]/.test(trimmed)) return false;
  43. // Reject quotes to keep Content-Disposition predictable/safe
  44. if (trimmed.includes('"')) return false;
  45. // Ensure it's a basename (no sneaky segments)
  46. if (path.basename(trimmed) !== trimmed) return false;
  47. return true;
  48. }
  49. function isPdfFilename(name) {
  50. return typeof name === "string" && name.toLowerCase().endsWith(".pdf");
  51. }
  52. function validateParamsOrThrow({ branch, year, month, day, filename }) {
  53. if (!BRANCH_RE.test(branch)) {
  54. throw badRequest("VALIDATION_BRANCH", "Invalid branch parameter", {
  55. branch,
  56. });
  57. }
  58. if (!YEAR_RE.test(year)) {
  59. throw badRequest("VALIDATION_YEAR", "Invalid year parameter", { year });
  60. }
  61. if (!MONTH_RE.test(month)) {
  62. throw badRequest("VALIDATION_MONTH", "Invalid month parameter", { month });
  63. }
  64. if (!DAY_RE.test(day)) {
  65. throw badRequest("VALIDATION_DAY", "Invalid day parameter", { day });
  66. }
  67. if (!isSafeFilename(filename)) {
  68. throw badRequest("VALIDATION_FILENAME", "Invalid filename parameter", {
  69. filename,
  70. });
  71. }
  72. if (!isPdfFilename(filename)) {
  73. throw badRequest(
  74. "VALIDATION_FILE_EXTENSION",
  75. "Only PDF files are allowed",
  76. { filename }
  77. );
  78. }
  79. }
  80. function resolvePdfPathOrThrow({ root, branch, year, month, day, filename }) {
  81. const rootAbs = path.resolve(root);
  82. const absPath = path.resolve(rootAbs, branch, year, month, day, filename);
  83. // Ensure the resolved path stays within NAS_ROOT_PATH
  84. const rel = path.relative(rootAbs, absPath);
  85. if (rel.startsWith("..") || path.isAbsolute(rel)) {
  86. throw badRequest("VALIDATION_PATH_TRAVERSAL", "Invalid file path", {
  87. branch,
  88. year,
  89. month,
  90. day,
  91. filename,
  92. });
  93. }
  94. return absPath;
  95. }
  96. /**
  97. * GET /api/files/:branch/:year/:month/:day/:filename
  98. *
  99. * Query (optional):
  100. * - download=1 | download=true => Content-Disposition: attachment
  101. * - default => inline
  102. */
  103. export const GET = withErrorHandling(
  104. async function GET(request, ctx) {
  105. const session = await getSession();
  106. if (!session) {
  107. throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
  108. }
  109. const { branch, year, month, day, filename } = await ctx.params;
  110. const missing = [];
  111. if (!branch) missing.push("branch");
  112. if (!year) missing.push("year");
  113. if (!month) missing.push("month");
  114. if (!day) missing.push("day");
  115. if (!filename) missing.push("filename");
  116. if (missing.length > 0) {
  117. throw badRequest(
  118. "VALIDATION_MISSING_PARAM",
  119. "Missing required route parameter(s)",
  120. { params: missing }
  121. );
  122. }
  123. if (!canAccessBranch(session, branch)) {
  124. throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
  125. }
  126. validateParamsOrThrow({ branch, year, month, day, filename });
  127. const root = getNasRootOrThrow();
  128. const absPath = resolvePdfPathOrThrow({
  129. root,
  130. branch,
  131. year,
  132. month,
  133. day,
  134. filename,
  135. });
  136. const details = { branch, year, month, day, filename };
  137. let stat;
  138. try {
  139. stat = await fsp.stat(absPath);
  140. } catch (err) {
  141. throw await mapStorageReadError(err, { details });
  142. }
  143. if (!stat.isFile()) {
  144. throw notFound("FS_NOT_FOUND", "Not found", details);
  145. }
  146. const { searchParams } = new URL(request.url);
  147. const download = (searchParams.get("download") || "").toLowerCase();
  148. const asAttachment = download === "1" || download === "true";
  149. const dispositionType = asAttachment ? "attachment" : "inline";
  150. const contentDisposition = `${dispositionType}; filename="${filename}"`;
  151. const nodeStream = fs.createReadStream(absPath);
  152. const webStream = Readable.toWeb(nodeStream);
  153. return new Response(webStream, {
  154. status: 200,
  155. headers: {
  156. "Content-Type": "application/pdf",
  157. "Content-Disposition": contentDisposition,
  158. "Content-Length": String(stat.size),
  159. "Cache-Control": "no-store",
  160. "X-Content-Type-Options": "nosniff",
  161. },
  162. });
  163. },
  164. { logPrefix: "[api/files/[branch]/[year]/[month]/[day]/[filename]]" }
  165. );